2026_air_modular_mix.py

#

SPDX-FileCopyrightText: 2026 Richard Penaud & Gijs Netto SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be

SPDX-License-Identifier: GPL-3.0-or-later

import math
import random

import bpy
#
def clean_collection(collection_name):
#
  1. Vérifier si la collection existe pour éviter les erreurs
    if collection_name in bpy.data.collections:
        col = bpy.data.collections[collection_name]
#
  1. Boucler à l’envers sur les objets de la collection et les supprimer
        for obj in list(col.objects):
            bpy.data.objects.remove(obj, do_unlink=True)
#
  1. Purger les données orphelines pour libérer la mémoire
        bpy.ops.outliner.orphans_purge(
            do_local_ids=True,
            do_linked_ids=True,
            do_recursive=True,
        )
#

— APPEL DE LA FONCTION —

clean_collection("modular")
#

FONCTIONS###################################################################################

def box(_pos, _size):
    bpy.ops.mesh.primitive_cube_add(size=_size, location=_pos)
    return bpy.context.active_object
#

Modular et Violon#####################################################################”

#
def convert_to_mesh(_obj):
    bpy.ops.object.select_all(action="DESELECT")
    _obj.select_set(True)
    bpy.context.view_layer.objects.active = _obj
    bpy.ops.object.convert(target="MESH")
    return bpy.context.active_object
#
def add_metaball(_mball_obj, _pos, _radius):
    element = _mball_obj.data.elements.new()
    element.co = _pos
    element.radius = _radius
#
def boolean_intersect(operandA, operandB):
    mod = operandA.modifiers.new(name="_boolean", type="BOOLEAN")
    mod.operation = "INTERSECT"
    mod.object = operandB
    mod.solver = "EXACT"
    bpy.ops.object.select_all(action="DESELECT")
    operandA.select_set(True)
    bpy.context.view_layer.objects.active = operandA
    bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(operandB, do_unlink=True)
#
def boolean_difference(operandA, operandB):
    mod = operandA.modifiers.new(name="_boolean", type="BOOLEAN")
    mod.operation = "DIFFERENCE"
    mod.object = operandB
    mod.solver = "EXACT"
    bpy.ops.object.select_all(action="DESELECT")
    operandA.select_set(True)
    bpy.context.view_layer.objects.active = operandA
    bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(operandB, do_unlink=True)
#
def boolean_union(operandA, operandB):
    mod = operandA.modifiers.new(name="_boolean", type="BOOLEAN")
    mod.operation = "UNION"
    mod.object = operandB
    mod.solver = "EXACT"
    bpy.ops.object.select_all(action="DESELECT")
    operandA.select_set(True)
    bpy.context.view_layer.objects.active = operandA
    bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(operandB, do_unlink=True)
#

2D################################################################################

#
def _text(text_list, location=(0, 0, 0), size=1.0):
#
  1. Joindre la liste de chaînes avec des sauts de ligne
    full_text = "\n".join(text_list)
#
  1. Créer l’objet texte
    bpy.ops.object.text_add(location=location)
    text_obj = bpy.context.active_object
#
  1. Assigner le texte et les propriétés de base
    text_obj.data.body = full_text
    text_obj.data.size = size  # Règle la taille de la police
#
  1. Optionnel : Options d’alignement (CENTER, LEFT, RIGHT, JUSTIFY)
    text_obj.data.align_x = "LEFT"

    return text_obj
#
def bezier_rectangle(name, width, height, location=(0, 0, 0)):
#
  1. Créer la structure de la courbe de Bézier
    curve_data = bpy.data.curves.new(name=name, type="CURVE")
    curve_data.dimensions = "3D"
#

Créer une spline (une ligne de courbe) dans cette structure

    spline = curve_data.splines.new(type="BEZIER")
    spline.use_cyclic_u = True  # Ferme la courbe (relie le dernier point au premier)
#

Par défaut, une nouvelle spline a déjà 1 point. On en ajoute 3 autres pour faire 4 coins

    spline.bezier_points.add(3)
#
  1. Définir les demi-dimensions pour centrer le rectangle sur sa location
    w = width / 2
    h = height / 2
#

Coordonnées des 4 coins (X, Y, Z)

    coords = [
        (-w, -h, 0),
        (w, -h, 0),
        (w, h, 0),
        (-w, h, 0),
    ]
#
  1. Assigner pos
    for i, pt in enumerate(spline.bezier_points):
        pt.co = coords[i]
        pt.handle_left_type = "VECTOR"
        pt.handle_right_type = "VECTOR"

    obj = bpy.data.objects.new(name, curve_data)
    obj.location = location
#

Lier

    bpy.context.collection.objects.link(obj)
    return obj
#

transition - grid#######################################################################

def generateGrid(_GridName, size_val, location_tuple):
    bpy.ops.mesh.primitive_grid_add(
        x_subdivisions=45,
        y_subdivisions=45,
        location=location_tuple,
        size=size_val,
    )
    bpy.context.object.name = _GridName
#
def Remodel(_Mesh, size_val):
    myObj = bpy.data.objects[_Mesh]
    num_bumps = random.randint(5, 8)
    bumps = []
    half_size = size_val / 2.0

    for _ in range(num_bumps):
        bumps.append(
            {
                "center_x": random.uniform(-half_size * 1.1, half_size * 1.1),
                "center_y": random.uniform(-half_size * 1.1, half_size * 1.1),
                "radius": random.uniform(half_size * 0.6, half_size * 1.2),
                "amplitude": random.uniform(-2.0, 2.0),
            },
        )

    for v in myObj.data.vertices:
        ratio_x = (v.co.x + half_size) / size_val
        vague_sinus = math.sin((ratio_x * math.pi) - (math.pi / 2.0))
        v.co.z = (size_val * 0.25) + ((size_val * 0.35) * vague_sinus)

        for b in bumps:
            dist = math.sqrt(
                (v.co.x - b["center_x"]) ** 2 + (v.co.y - b["center_y"]) ** 2
            )
            if dist < b["radius"]:
                influence = (math.cos((dist / b["radius"]) * math.pi) + 1.0) / 2.0
                v.co.z += b["amplitude"] * influence
#
def stretch_it(_Mesh, size_val):
    obj = bpy.data.objects[_Mesh]
    half_size = size_val / 2.0
    margin = size_val * 0.012

    for v in obj.data.vertices:
        if (half_size - margin) < v.co.x < (half_size + margin):
            v.co.x = half_size * 1.4
        if (-half_size + margin) > v.co.x > (-half_size - margin):
            v.co.x = -half_size * 1.4
        if (half_size - margin) < v.co.y < (half_size + margin):
            v.co.y = half_size * 1.2
        if (-half_size + margin) > v.co.y > (-half_size - margin):
            v.co.y = -half_size * 1.2
#
def _Modifiers(_Mesh, _Thick):
    myObj = bpy.data.objects[_Mesh]
    smooth_it = myObj.modifiers.new(name="smooth_it", type="SUBSURF")
    smooth_it.levels = 4

    extrusion = myObj.modifiers.new(name="extrusion", type="SOLIDIFY")
    extrusion.thickness = _Thick
#

EXE########################################################################################

bpy.ops.object.camera_add(
    enter_editmode=False,
    align="VIEW",
    location=(10, 10, 10),
    rotation=(0, 0, 0),
)
bpy.context.object.data.type = "ORTHO"
bpy.context.object.data.ortho_scale = 32

bpy.ops.object.camera_add(
    enter_editmode=False,
    align="VIEW",
    location=(10, -10, 15),
    rotation=(math.pi / 4, 0, math.pi / 4),
)
bpy.context.object.data.type = "ORTHO"
bpy.context.scene.render.resolution_x = 1080
bpy.context.object.data.ortho_scale = 32
#

ou dans le morceau

_partie = random.randint(1, 5)
#

2D

bpy.ops.curve.primitive_nurbs_circle_add(location=(-3, 22, 0))
bpy.ops.transform.resize(value=(2, 1, 1))
bpy.ops.curve.primitive_nurbs_circle_add(location=(4, 22, 0))
bpy.ops.transform.resize(value=(5, 1, 1))
bpy.ops.curve.primitive_nurbs_circle_add(location=(12, 22, 0))
bpy.ops.transform.resize(value=(3, 1, 1))
bpy.ops.curve.primitive_nurbs_circle_add(location=(20, 22, 0))
bpy.ops.transform.resize(value=(5, 1, 1))

if _partie == 1:
    bpy.ops.object.text_add(location=(-5, 18, 0), scale=(1, 1, 1))
    text_obj = bpy.context.active_object
    text_obj.data.body = "Partie I: intro"
    bezier_rectangle(
        name="RectangleBezier", width=4.0, height=4.0, location=(-4, 22, 0)
    )
    l_texte = [
        "0'00-0'40",
        "génération de la grille des bass, drums et cords.",
        "génération du violon.",
    ]
    _text(l_texte, location=(-5, 17, 0), size=0.5)

if _partie == 2:
    bpy.ops.object.text_add(location=(-5, 18, 0), scale=(1, 1, 1))
    text_obj = bpy.context.active_object
    text_obj.data.body = "Transition entre intro et partie 2"
    bezier_rectangle(
        name="RectangleBezier", width=3.0, height=4.0, location=(-1, 22, 0)
    )
    l_texte = [
        "0'40-1'00",
        "génération de la grille des bass, drums et cords.",
        "génération du violon et du modular.",
        "génération de la vague de transition.",
    ]
    _text(l_texte, location=(-5, 17, 0), size=0.5)

if _partie == 3:
    bpy.ops.object.text_add(location=(-5, 18, 0), scale=(1, 1, 1))
    text_obj = bpy.context.active_object
    text_obj.data.body = "Partie 2"
    bezier_rectangle(
        name="RectangleBezier", width=12.0, height=4.0, location=(7, 22, 0)
    )
    l_texte = [
        "1'00-3'50",
        "génération de la grille des bass, drums et cords.",
        "génération du modular.",
    ]
    _text(l_texte, location=(-5, 17, 0), size=0.5)

if _partie == 4:
    bpy.ops.object.text_add(location=(-5, 18, 0), scale=(1, 1, 1))
    text_obj = bpy.context.active_object
    text_obj.data.body = "Transition entre la partie 2 et la partie finale"
    bezier_rectangle(
        name="RectangleBezier", width=4.0, height=4.0, location=(15, 22, 0)
    )
    l_texte = [
        "3'50-4'20",
        "génération de la grille des bass, drums et cords.",
        "génération du modular et du violon.",
        "génération de la vague de transition.",
    ]
    _text(l_texte, location=(-5, 17, 0), size=0.5)

if _partie == 5:
    bpy.ops.object.text_add(location=(-5, 18, 0), scale=(1, 1, 1))
    text_obj = bpy.context.active_object
    text_obj.data.body = "Partie finale"
    bezier_rectangle(
        name="RectangleBezier", width=7.0, height=4.0, location=(20, 22, 0)
    )
    l_texte = [
        "4'20-5'59",
        "génération de la grille des bass, drums et cords.",
        "génération du violon.",
    ]
    _text(l_texte, location=(-5, 17, 0), size=0.5)
#

Settings

c_ = 10  # Taille de la scène
d_ = 5  # zone des mball debut
num_layers = 5  # Nombre de couches verticales
balls_per_layer = 5  # Nombre de metaballs par couche
layer_height = 2.0  # Hauteur entre chaque couche
num_lines = 3
balls_per_line = 3
shell_thickness = 0.3
#

STRUCTURE###################################################################################### grille Bass / Drums / Cords

if "modular" in bpy.data.collections:
    col = bpy.data.collections["modular"]
else:
    col = bpy.data.collections.new("modular")
    bpy.context.scene.collection.children.link(col)


all_rods = []
YZ_rods = []
XZ_rods = []

drums = [-4.75, -2.5, 0.5, 3.5, 4.75]  # trio serré + espacées + trio serré
cords = [-4.85, -3.5, -1, 2, 4.85]  # 2 proches + 3 espacées
bass = [-4.6, -2.5, 2, 3.3, 4.6]  # duo + vide + trio serré

w_drums = 0.5
w_cords = 0.3
w_bass = 0.8

for ox in drums:
    for oy in cords:
#

tige verticale Z

        bpy.ops.mesh.primitive_cube_add(
            size=1,
            location=(ox, oy, 0),
            scale=(w_drums, w_cords, c_),
        )
        rod = bpy.context.active_object
        all_rods.append(rod)

base = all_rods[0]
for rod in all_rods[1:]:
    boolean_union(base, rod)

copieYZ = base.copy()
copieYZ.data = base.data.copy()
copieYZ.name = "copieYZ"
bpy.context.collection.objects.link(copieYZ)

copieXZ = base.copy()
copieXZ.data = base.data.copy()
copieXZ.name = "copieXZ"
bpy.context.collection.objects.link(copieXZ)

for oy in cords:
    for oz in bass:
#

tige horizontale X

        bpy.ops.mesh.primitive_cube_add(
            size=1,
            location=(0, oy, oz),
            scale=(c_, w_cords, w_bass),
        )
        rod = bpy.context.active_object
        YZ_rods.append(rod)

baseYZ = YZ_rods[0]
for rod in YZ_rods[1:]:
    boolean_union(baseYZ, rod)

boolean_difference(baseYZ, copieYZ)

for ox in drums:
    for oz in bass:
#

tige horizontale Y (fine)

        bpy.ops.mesh.primitive_cube_add(
            size=1,
            location=(ox, 0, oz),
            scale=(w_drums, c_, w_bass),
        )
        rod = bpy.context.active_object
        XZ_rods.append(rod)

baseXZ = XZ_rods[0]
for rod in XZ_rods[1:]:
    boolean_union(baseXZ, rod)

boolean_difference(baseXZ, copieXZ)
#

Fusionner toutes les tiges

boolean_union(base, baseYZ)
boolean_union(base, baseXZ)


base.name = "grid_final"


if _partie == 1 or _partie == 2 or _partie == 5:
#

VIOLON###################################################################################### Étape 1 : Création des metaballs (ellipsoïdes)

    bpy.ops.object.metaball_add(type="BALL", location=(0, 0, 0), radius=0.001)
    mball_violon = bpy.context.active_object
    mball_violon.name = "mball_violon"
    mball_violon.data.resolution = 0.2

    randnom = random.randint(2, 4)

    for r in range(randnom):
        num_balls = 32  # Nombre total de billes pour former la spirale
        radius = d_ * random.randint(1, 3) / 4  # Rayon de la spirale (adapté à ton d_)
        height = d_ * random.randint(1, 3) / 2  # Hauteur totale de la spirale
        randangle = random.randint(-3, 3) / 2
        count = 20

        for i in range(num_balls):
#

‘t’ varie de 0 à 1 le long de la spirale

            t = i / (num_balls - 1)
#

Un tour complet correspond à 2 * pi radians

            angle = math.pi * t + randangle
#

Calcul des coordonnées (Cercle en X,Y et montée en Z)

            px = radius * math.cos(angle)
            py = radius * math.sin(angle)
            if randangle in range(-3, 3, 1):
                pz = (t * height) - (height / 2)  # Centré sur l'origine
            else:
                pz = (-t * height) + (height / 2)  # Centré sur l'origine
#

Taille et épaisseur comme dans ton code

            _size = count / 30
            pos = (px, py, pz)

            add_metaball(mball_violon, pos, _size)
            count += 1
#

Étape 2 : Conversion en mesh

    mesh_violon = convert_to_mesh(mball_violon)
    mesh_violon.name = "mesh_violon"

if _partie == 2 or _partie == 3 or _partie == 4:
#

MODULAR####################################################################################### étape 1 : metaballs

    bpy.ops.object.metaball_add(type="BALL", location=(0, 0, 0), radius=0.001)
    mball_outer = bpy.context.active_object
    mball_outer.name = "mball_outer"
    mball_outer.data.resolution = 0.2

    bpy.ops.object.metaball_add(type="BALL", location=(0, 0, 0), radius=0.001)
    mball_inner = bpy.context.active_object
    mball_inner.name = "mball_inner"
    mball_inner.data.resolution = 0.2

    num_lines = 7
    balls_per_line = 7

    for line in range(num_lines):
        px = random.uniform(-d_ / 2, d_ / 2)
        py = random.uniform(-d_ / 2, d_ / 2)
        pz = random.uniform(-d_ / 2, d_ / 2)
        dx = random.uniform(-1, 1)
        dy = random.uniform(-1, 1)
        dz = random.uniform(-1, 1)
        step = random.uniform(0.8, 1.4)

        for i in range(balls_per_line):
            _size = random.uniform(1, 4)
            pos = (px + dx * i * step, py + dy * i * step, pz + dz * i * step)
            add_metaball(mball_outer, pos, _size)
            add_metaball(mball_inner, pos, _size - shell_thickness)
#

étape 2 : conversion mesh

    mesh_outer = convert_to_mesh(mball_outer)
    mesh_outer.name = "mesh_outer"

    mesh_inner = convert_to_mesh(mball_inner)
    mesh_inner.name = "mesh_inner"
#

Backup pour la grille

    modular_backup = mesh_outer.copy()
    modular_backup.data = mesh_outer.data.copy()
    modular_backup.name = "modular_backup"
    bpy.context.collection.objects.link(modular_backup)
#

étape 3 : booleans metaballs

    work_volume = box((0, 0, 0), c_)
    boolean_intersect(work_volume, mesh_outer)
    boolean_difference(work_volume, mesh_inner)
    work_volume.name = "modular"
    boolean_difference(base, modular_backup)


if _partie == 2 or _partie == 4:
#

TRANSITION#####################################################################################

#

GENERATION DE MYGRID ALEATOIRE

#
  1. Dimension aléatoire entre 2 et 8
    random_size = random.uniform(4, 8)
#
  1. Position aléatoire libre dans le grand cube de 12x12x12 (centré sur 0)
    cube_bound = 12 / 2
    rand_x = random.uniform(-cube_bound, cube_bound)
    rand_y = random.uniform(-cube_bound, cube_bound)
    rand_z = random.uniform(-cube_bound, cube_bound)
    random_location = (rand_x, rand_y, rand_z)
#

Production du maillage de base

    generateGrid("myGrid", random_size, random_location)
    Remodel("myGrid", random_size)
    stretch_it("myGrid", random_size)
    _Modifiers("myGrid", shell_thickness)
#

Conversion en mesh destructif pour pouvoir appliquer les booléens correctement

    mesh_outer = convert_to_mesh(bpy.data.objects["myGrid"])
    mesh_outer.name = "transition"
#

Appliquer le visuel final (lissage des normales)

    bpy.ops.object.shade_smooth()
#

DECOUPE PAR LE CUBE

#

Création du cadre du cube (volume de travail de dimension c_ = 10)

    work_volume = box((0, 0, 0), c_)
#

Intersection : On ne garde de myGrid QUE ce qui est à l’intérieur du cube de 10x10x10

    boolean_intersect(mesh_outer, work_volume)
#

Note : mesh_outer est maintenant le plan découpé net aux dimensions du cube.

#

fin!